feat(insights): subscribers tab (NPPD-1616)#217
Conversation
Top-level Insights_Wizard extending Wizard with slug newspack-insights, parent_menu newspack-dashboard (nests under the top-level Newspack admin menu — matches Setup wizard precedent), capability manage_options. The React view is registered separately in src/wizards/index.tsx under the slug key. enqueue_scripts_and_styles() localizes a 'newspackInsights' boot config: tab visibility (stubbed all-on pending NPPD-1598 BQ wrapper + Woo queries for real feature detection), default date range (last 30 days), default comparison mode (off), site timezone, settings URL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One section class per tab: - Insights_Section_Audience (Audience) - Insights_Section_Engagement (Engagement) - Insights_Section_Conversion (Conversion Journey) - Insights_Section_Gates (Gates) - Insights_Section_Prompts (Prompts) - Insights_Section_Subscribers (Subscribers) - Insights_Section_Donors (Donors) - Insights_Section_Advertising (Advertising) Each is a plain class (NOT extending Wizard_Section, NOT registered via the wizard's sections array) with: - SECTION_NAME constant matching the React tab label - static init() that calls self::register_hooks() - empty register_hooks() — placeholder for future per-tab REST endpoint registration as each tab's data layer lands (NPPD-1604, 1607, 1608, 1609, 1616, 1617, 1618, 1624) - Doc block describing tab scope and visibility constraints This is a new convention introduced for Insights: tab routing happens on the React side via URL query persistence, so PHP doesn't register 8 separate wizards (like Audience does) — these classes exist as the documented hook point for future REST work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire up the 9 new PHP files: - includes/class-newspack.php: include_once for the Insights_Wizard class file plus all 8 section stub files (alphabetical within the insights/ subdir grouping) - includes/class-wizards.php: add 'insights' => new Insights_Wizard() to the $wizards array, positioned between audience-integrations and listings (matches the visual order in admin) - includes/class-wizards.php: call ::init() on all 8 section classes at the tail of init_wizards() so their (currently empty) register_hooks() runs during the 'init' action — placeholder hookpoint for future per-tab REST work Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…PPD-1602) useDateRange (state/useDateRange.ts): - DateRangePreset type with 6 presets: last-7, last-30, last-30 (default), last-90, this-month, last-month, custom - Hydrates initial state from URL query params (range, start, end) with fallback to boot config default - Persists changes via history.replaceState (no history pollution) - Exports computeRangeForPreset() pure helper for testing - Validates URL inputs against /^\d{4}-\d{2}-\d{2}$/ before trusting useComparisonMode (state/useComparisonMode.ts): - Boolean state for "compare to previous period" toggle - Hydrates from ?compare=1 in URL; default off - Computes previous-period range as same-length-back (immediately preceding current window, no overlap) via computePreviousRange() pure helper, memoized against current range - previousRange is null when comparison disabled or current range is malformed Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le, LastUpdated (NPPD-1602) DateRangePicker (components/DateRangePicker.tsx): - Stateless: caller wires to useDateRange - Native <select> for preset choice (6 options pulled from DATE_RANGE_PRESETS) - Custom mode reveals two type="date" inputs separated by an arrow ComparisonToggle (components/ComparisonToggle.tsx): - Stateless: caller wires to useComparisonMode - Single checkbox: "Compare to previous period" LastUpdated (components/LastUpdated.tsx): - Takes an ISO 8601 timestamp prop (or null if not yet known) - Renders relative time ("Updated 12 minutes ago", "Updated 3 hours ago", "Updated 2 days ago", "Updated just now") - Title attribute holds the absolute timestamp for tooltip on hover - Renders nothing if timestamp is null or unparseable — safe for boot state before first cache hit Per spec at ~/Sites/insights-docs/component-design-spec.md. All three intentionally lean on native HTML inputs (no @wordpress/components dependencies) so the chrome can render before WP-data hydration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TabNavigation (components/TabNavigation.tsx): - Exports TabKey type and ALL_TABS list (single source of truth for tab identity + display label) - Stateless: active state via prop, click via callback - ARIA-correct: role="tablist" on nav, role="tab" + aria-selected + aria-controls per button - Conditional visibility per TabVisibility prop (record per TabKey). Hidden tabs are filtered out entirely (not rendered with display:none). TabContent (components/TabContent.tsx): - Lazy-loads each of the 8 tab components via React.lazy - Suspense boundary wraps the render with a simple "Loading…" fallback - Switch-based dispatch on activeTab (one place to thread the lazy imports) - ARIA-correct: role="tabpanel", id/aria-labelledby paired with the TabNavigation button IDs - Passes activeTab, range, previousRange down to each tab. Tabs receive prop-shape they need for future data fetching even though current stubs ignore them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ders (NPPD-1602) Eight files in src/wizards/insights/tabs/: - AudienceTab.tsx (real content: NPPD-1604) - EngagementTab.tsx (NPPD-1607) - ConversionTab.tsx (NPPD-1608) - GatesTab.tsx (NPPD-1609) - PromptsTab.tsx (NPPD-1616) - SubscribersTab.tsx (NPPD-1617) - DonorsTab.tsx (NPPD-1618) - AdvertisingTab.tsx (NPPD-1624) Each renders a centered tab name + "Coming soon" using shared .newspack-insights__tab-stub styles defined alongside the wizard chrome. Each is its own file so TabContent's React.lazy() can code-split per tab; this issue's bundle just gets 8 trivial chunks that future PRs replace with the real per-tab data flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ties the chrome together: - Owns active tab state with URL persistence (?tab=...). Initial tab hydrates from URL with validation against visibility config; if the URL-named tab is hidden for this publisher, falls back to the first visible tab (handles the edge where someone bookmarks an Advertising tab and the publisher hasn't enabled GAM yet). - Threads useDateRange and useComparisonMode (both URL-persistent). - Renders the page in three slots: header (title + date picker + comparison toggle + last-updated, all in one row), tab navigation, and lazy tab content. - previousRange from useComparisonMode flows through TabContent to tabs so future per-tab data fetching can request both windows. InsightsBootConfig type documents the wp_localize_script payload shape in one place; the PHP wizard's get_boot_config() and this type should stay in sync as the real feature-detection logic lands (NPPD-1598+). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…PD-1602) src/wizards/insights/index.tsx: - Reads window.newspackInsights (populated by PHP via wp_localize_script) - Falls back to a hardcoded all-tabs-on / last-30-days config if missing (defensive; the PHP path should always populate, but the React module can render in isolation for development without a runtime crash) - Mounts <InsightsWizard config={...} /> as the default export src/wizards/index.tsx: - Adds 'newspack-insights' key to the lazy-loaded components map - Code-split into its own chunk via /* webpackChunkName: "insights-wizard" */ so the Insights bundle stays out of the shared newspack-wizards chunk and only loads when ?page=newspack-insights Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Page-level styles for the Insights wizard chrome only — per-component data viz styles live in packages/components/src/ and don't belong here. - Page: 1200px max-width, 24px horizontal padding, gray-900 base - Header: flex row (title left, picker/toggle/timestamp right), wrap to multiple rows on narrow widths - Title: 28px / 600 (matches the data viz demo gallery convention) - DateRangePicker: native <select> + date inputs styled to match WP admin form controls (gray-300 border, 4px radius, focus-visible outline in admin theme color) - ComparisonToggle: inline checkbox with 13px label - Tab nav: horizontal bar with bottom border. Active tab gets the admin theme color + bottom-border underline. Tabs wrap to multiple rows on narrow widths. - Tab content area: 320px min-height so the page doesn't collapse on Suspense fallback - Tab stub: centered title + "Coming soon" with 320px min-height Per spec at ~/Sites/insights-docs/component-design-spec.md type scale and color usage rules. wp-colors for all neutrals. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ocks The 8 PHP section stubs and 8 corresponding React tab stubs were documented with an inverted Linear issue map (original prompt's draft numbering, not the actual issue assignments). Confirmed correct mapping is: Insights_Section_Audience -> NPPD-1608 Insights_Section_Engagement -> NPPD-1624 Insights_Section_Conversion -> NPPD-1609 Insights_Section_Gates -> NPPD-1604 Insights_Section_Prompts -> NPPD-1607 Insights_Section_Subscribers -> NPPD-1616 Insights_Section_Donors -> NPPD-1617 Insights_Section_Advertising -> NPPD-1618 Doc-block-only change across 16 files (8 PHP + 8 TSX). No behavior change. The TSX tab stub doc blocks had the same misalignment as the PHP section docs, so they're updated in the same commit for consistency — the user's directive scoped to "section stub doc blocks" but leaving the tab stubs inconsistent would have introduced a different drift across the same surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Last N days" presets were subtracting N days from today, producing an (N+1)-day inclusive window — e.g. on Jun 3, last-30 returned May 4 → Jun 3 inclusive, which is 31 days. Subtract (N-1) instead so the window is exactly N days end-to-end (May 5 → Jun 3 = 30 days inclusive). Fixes Copilot review on PR #210 in three places: - includes/wizards/insights/class-insights-wizard.php: PHP boot config default range (also added comment confirming current_datetime() returns DateTimeImmutable so modify() is non-mutating — Copilot flagged this as a mutation risk but WP docs and runtime behavior say otherwise; the real bug was the off-by-one, not mutation) - src/wizards/insights/state/useDateRange.ts: computeRangeForPreset for last-7 / last-30 / last-90 - src/wizards/insights/index.tsx: FALLBACK_CONFIG default range Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n __() Fixes Copilot review on PR #211. Previously the user-facing strings in TabNavigation (8 tab labels + the nav aria-label) and useDateRange (6 preset labels) were hardcoded English. Wrapped each in __() with the 'newspack-plugin' text domain so wp-scripts string extraction picks them up for the .pot file and wp-admin renders them in the active locale. Module-load-time __() calls are fine — wp-i18n does runtime translation lookup against bundled translations and locale doesn't change within a session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes two Copilot review points on PR #211: 1. settingsUrl was in InsightsBootConfig but unused. Now rendered as a "Settings" link in the chrome header-right group (always visible when the URL is present, even in the empty-state case where the rest of the header tools hide — settings should remain reachable for configuration). 2. When visibility config had zero true tabs, the chrome rendered a blank tab area (TabNavigation empty, TabContent rendering audience panel anyway because readInitialTab forced 'audience'). Now: - readInitialTab returns null when no tabs are visible - InsightsWizard renders a dedicated empty state ("No insights sections available") with a brief explanation directing the user to Settings - DateRangePicker / ComparisonToggle / LastUpdated hide too — they're not actionable without any tab to display Empty-state SCSS follows the spec's empty-state vocabulary (centered, generous padding, 22px title + 14px gray-700 message, max-width 480px for readability). The timezone field stays in InsightsBootConfig for future date-formatting use (e.g., LastUpdated's absolute tooltip; per-tab data renderers when real data arrives); not yet consumed, but the PHP side already populates it so removing now means adding back later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…comment) CI on PR #211 surfaced lint failures the local build alone didn't catch. This commit makes the lint checks pass: PHP (phpcs / phpcbf): - Aligned associative array double-arrows in get_boot_config() - Converted the block-comment annotation above 'tabs' to // comments to satisfy WordPress.Files.SpaceBeforeBlockComment (a leading blank line inside an array literal reads worse than just using line comments) JS / TS (prettier + jsx-a11y): - Prettier-formatted all insights tab stubs and components to satisfy the project's prettier config (line-break style on inline JSX) - Added htmlFor/id pairs to <label> + control associations in ComparisonToggle and DateRangePicker for jsx-a11y/label-has-associated-control - Switched TabNavigation's root from <nav role="tablist"> to <div role="tablist"> for jsx-a11y/no-noninteractive-element-to-interactive-role (nav is non-interactive; tablist is interactive) Net behavior unchanged; doc blocks intact; visible markup identical modulo the nav→div swap which is a11y-correct. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…constant Gates Insights wizard registration, asset enqueueing, and section stub initialization behind NEWSPACK_INSIGHTS_ENABLED. Default: off. Allows merging subsequent Insights PRs incrementally to main without exposing the in-progress feature to publishers. The flag is removed once Insights is ready for general release. Pattern follows Private_Tags::is_enabled() (includes/tags/class-private-tags.php). Gating points: - Insights_Wizard::__construct() bails before parent::__construct() runs, so no admin_menu / admin_enqueue_scripts / admin_body_class hooks are registered. The object exists in the Wizards $wizards array but is a no-op. wp-admin: no menu item, no asset enqueue, the page returns WP's "do not have sufficient permissions" since the slug isn't registered. - All 8 section stub init() methods (Audience, Engagement, Conversion, Gates, Prompts, Subscribers, Donors, Advertising) bail before calling register_hooks(). They're no-ops today; the gate belongs here so when per-tab REST registration lands in subsequent PRs it inherits the gate for free. Verified at runtime: with NEWSPACK_INSIGHTS_ENABLED undefined, is_enabled() returns false and the admin_menu add_page hook is not attached to the wizard instance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 typed methods, one per Tab 6 metric, fixing the PHP boundary that both backend implementations (HPOS, legacy CPT) will satisfy: - get_active_non_donation_subscribers - get_new_subscribers_in_window - get_churned_subscribers_in_window - get_mrr / get_arr - get_subscription_revenue_gross / _net - get_subscription_refund_rate - get_subscription_tenure_distribution - get_upcoming_renewals_30d - get_failed_payment_retry_rate - get_performance_by_product - get_cancellation_reasons Donation product IDs are injected at construction (not threaded through each method signature) so the per-method contracts stay clean — see Donation_Product_Classifier::get_donation_product_ids(). Namespace: \Newspack\Insights\Storage_Interface. Sub-namespace matches the prompt's notation (Insights\Storage_Interface) and the prior section stubs in the chrome's \Newspack namespace remain unaffected. SQL bodies live in: - ~/Sites/insights-docs/formulas/tab-6-subscribers.md - ~/Sites/insights-docs/formulas/subscription-donation-schema.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…PPD-1616) Reads the woocommerce_custom_orders_table_enabled option, returns either Storage_Detector::BACKEND_HPOS ('hpos') or Storage_Detector::BACKEND_LEGACY ('legacy'). Caches the result in a 24h transient since HPOS migration is a one-way event and the option rarely flips. Two entry points: - detect(): cached read, recomputes only on cache miss - force_refresh(): bypass + refresh cache, returns fresh value force_refresh() is the hook point for NPPD-1605's eventual cache invalidation layer and for the HPOS migration window where a single admin session might toggle the option mid-flight. The data_sync_enabled flag mentioned in the schema doc isn't relevant here — that affects which backend's reads are *trustworthy* but not which is *active*. The active backend is solely determined by the custom_orders_table_enabled option. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Storage_Interface against the HPOS tables ({prefix}wc_orders,
{prefix}wc_orders_meta, {prefix}wc_order_product_lookup). All 13
methods, SQL adapted from ~/Sites/insights-docs/formulas/tab-6-
subscribers.md.
Compat notes:
- WITH ... AS () CTEs in the formula doc are rewritten as inline
subqueries; MySQL 5.7 (which some Newspack-hosted publishers run)
does not support CTEs.
- Donation product IDs are injected at construction. Empty input
coerces to (0) via id_list() so NOT IN clauses stay syntactically
valid when a publisher has no donation products yet.
- Subscription product type IDs are looked up via term_relationships /
term_taxonomy at query time (subscription_product_ids_sql helper);
the metric-layer cache amortizes the lookup across calls.
- Date params bound via $wpdb->prepare with %s; product-ID lists
interpolated after intval cast to prevent SQL injection.
- Several PHPCS direct-DB-query phpcs:disable comments at the top —
this is an analytics layer that explicitly wants direct SQL, not
the WP query API.
Approximations called out:
- performance_by_product.lifetime_revenue sums renewal-amount rows
(the subscription parent's total_amount), not historical orders.
True LTV waits on the v1.1 BQ wrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Storage_Interface against the pre-HPOS WooCommerce order
storage: {prefix}posts (typed by post_type) and {prefix}postmeta.
Mirrors HPOS_Storage method-by-method.
Per-row translation per the schema doc:
HPOS Legacy
wc_orders.id posts.ID
wc_orders.type posts.post_type
wc_orders.status posts.post_status
wc_orders.date_created_gmt posts.post_date_gmt
wc_orders.customer_id postmeta._customer_user
wc_orders.total_amount postmeta._order_total (DECIMAL string)
wc_orders.parent_order_id posts.post_parent
wc_orders_meta.* postmeta.*
The product lookup table {prefix}wc_order_product_lookup is populated
by Woo Analytics regardless of backend, so it joins identically here.
Same compat constraints as HPOS_Storage: no CTEs (rewritten as inline
subqueries), donation IDs injected at construction, empty input coerces
to (0) for valid NOT IN syntax.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) Wraps the canonical \Newspack\Donations::is_donation_product() with an aggressive cache so Tab 6 SQL queries can thread a precomputed :donation_product_ids parameter into NOT IN filters without re-running the per-product detection logic. get_donation_product_ids() returns the union of all three detection paths from the schema doc: - Path 3 (universal): canonical Newspack donation family — grouped parent from the newspack_donation_product_id option plus the three children (once/month/year) - Path 1 (new, v6.41.0): products manually flagged via _newspack_is_donation postmeta — Donations::get_flagged_donation_ product_ids() - Path 2 (variation expansion): all product_variation post IDs whose parent is in the union of Paths 1+3. Necessary because the order product lookup table records the variation's product_id, not the parent's — a NOT IN filter using only parents would leak variation orders through. is_donation_product( $product_id ) tests against the cached set; safe for hot loops. flush_cache() is the hook point for the future NPPD-1605 cache invalidation layer and for manual recompute after configuration changes (newspack_donation_product_id option, _newspack_is_donation flag flips). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin dispatch + caching layer over the per-backend storage classes. Picks HPOS_Storage or Legacy_Storage via Storage_Detector::detect(), threads the precomputed donation product ID set from Donation_Product_Classifier::get_donation_product_ids() into the storage constructor, and wraps each metric call in a transient cache keyed by `prefix:backend:method:md5(params_json)`. Cache tiers: - 30 min (TTL_DEFAULT): windowed metrics and top-line snapshots — revenue gross/net, refund rate, new/churned counts, MRR, ARR, active count, upcoming renewals, retry rate - 60 min (TTL_HEAVY): heavy aggregation queries — tenure distribution, performance by product, cancellation reasons Comparison-mode is not implemented here: the REST layer calls these methods twice (current + prior window) and the cache makes the second call free if the prior window has already been requested. get_classification_metadata() exposes backend + donation_product_count + has_donation_family for the React classification banner. flush_all() is the hook point for NPPD-1605 invalidation and for manual recompute after corrections; not wired to any automatic trigger today because metrics expire on their own TTL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Subscribers_REST_Controller registering GET on the dedicated
namespace newspack-insights/v1 (separate from newspack/v1 which is
reserved for wizard infrastructure). Single route, single endpoint:
GET /newspack-insights/v1/subscribers
?start=YYYY-MM-DD
&end=YYYY-MM-DD
[&compare_start=YYYY-MM-DD&compare_end=YYYY-MM-DD]
Response shape:
- classification: { backend, donation_product_count, has_donation_family }
- snapshot: active_subscribers, mrr, arr, tenure_distribution,
upcoming_renewals_30d (window-independent)
- current: window + 7 windowed metrics for the requested range
- previous: same shape as current, or null when compare_*
params are omitted
Date inputs are Y-m-d in the site timezone; start resolves to 00:00:00
and end to 23:59:59 inclusive. Validation rejects malformed dates,
mismatched comparison-pair, and inverted windows with 400 errors.
The Insights_Section_Subscribers stub is expanded to:
- load_dependencies(): include_once the 7 Tab 6 PHP files in order
(interface -> detector -> storage backends -> classifier ->
orchestrator -> REST controller)
- register_hooks(): add_action('rest_api_init') to register the
controller's routes
Permission: manage_options, mirroring the wizard capability so the
data layer is only available to users who can view the tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squiz.Commenting.FunctionComment.MissingParamTag does not follow
{@inheritdoc} for @param resolution, so each windowed storage override
needs explicit @param tags even though the interface already documents
them. Added @param/@return to the 8 windowed methods in each of
HPOS_Storage and Legacy_Storage. No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Donation_Product_Classifier::compute_donation_product_ids() was calling \Newspack\Donations::get_parent_donation_product(), which is private. Read the option directly via Donations::DONATION_PRODUCT_ID_OPTION (a public const exposing 'newspack_donation_product_id') instead — same source of truth, no private-API coupling. Smoketest confirms both single-window and comparison-mode requests return the full classification/snapshot/current/previous payload with donation_product_count = 4 on a configured local site (grouped parent + once/month/year children). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
api/subscribers.ts:
- Source-of-truth TypeScript types mirroring the PHP response shape:
SubscribersResponse { classification, snapshot, current, previous },
with detail types for tenure rows, performance rows, and
cancellation reason rows.
- fetchSubscribersData(query) builds the URL and dispatches via
@wordpress/api-fetch. Comparison params are included only when both
compare_start and compare_end are provided.
hooks/useSubscribersData.ts:
- Owns Tab 6 fetch lifecycle. Refetches whenever range or
previousRange changes. Request-id guard prevents older slow calls
from overwriting newer ones on rapid range switches.
- Exposes idle / loading / success / error state plus a manual
refetch() for future force-refresh UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClassificationBanner: surfaces backend (HPOS vs legacy) + donation classification (excluded product count, or a muted warning when no donation family is configured). Renders at the top of the tab so publishers can verify Insights is reading the right slice. format.ts: Intl.NumberFormat helpers for number / currency (USD, v1) / percent / signed-percent delta. formatDelta() returns null when prior is zero (no defined ratio). MetricCard: scorecard atom. Label + big value + optional comparison delta with a11y label and up/down/flat directional class. Composed by Scorecard and Revenue sections. ScorecardSection: 6 cards — three snapshots (active subs, MRR, ARR), two windowed-with-delta (new, churned), one snapshot (upcoming renewals 30d count). RevenueSection: 4 cards — gross / net revenue, refund rate, failed payment retry rate. All windowed with delta vs previous window when comparison mode is on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TenureSection: computes box-plot stats (p25 / median / p75) and day-bucket counts client-side from the raw per-active-sub distribution returned by the server. Renders summary stats + a horizontal bar list for buckets (0-30, 31-90, 91-180, 181-365, 365+). PerformanceSection: top-50 products by active subscribers, rendered as a numeric table (active subs, churned subs, active value, lifetime revenue). Server applies the limit and the descending sort; no client-side sorting in v1. Lifetime revenue is the documented v1 approximation (sum of renewal-amount rows). CancellationReasonsSection: bucketed reasons with horizontal bars. 'unknown' is i18n'd; other slugs are humanized (underscores -> spaces, title case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the "Coming soon" stub. Calls useSubscribersData on the active range + comparison range, then composes: - ClassificationBanner (top) - ScorecardSection (active subs, MRR, ARR, new, churned, upcoming) - RevenueSection (gross, net, refund rate, retry rate) - TenureSection (box-plot stats + buckets) - PerformanceSection (top-50 product table) - CancellationReasonsSection (bar list) Local loading and error states. Wizard chrome (date picker, comparison toggle, tab nav) stays interactive in all states. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-formatted by Prettier (line widths, JSX spacing) and added the two missing /* translators: */ comments above the p25/p75 sprintf calls in TenureSection. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: centering was the opposite of what was wanted. Everything in the card now reads from the left edge: - Body wrapper: align-items center -> flex-start - Value: text-align center -> left - Description: text-align center -> left Label was already left-aligned. The accent line, vertical anchoring (hero at top, description at bottom), and min-height stay as-is. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Variable label wrap was shifting hero numbers out of vertical alignment across cards. "ACTIVE SUBSCRIBERS" fit on one line, but "MONTHLY RECURRING REVENUE" wrapped to two, pushing the hero number ~17px lower than its neighbors. Fix: set explicit line-height (1.4) on the label and reserve a min-height of 2 × line-height. Single-line labels now occupy the same vertical space as two-line labels, so hero numbers line up horizontally across the row regardless of label length. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…easons - Hero value font-weight 600 -> 500. The 600 felt too heavy for the 44px size. - Description gets a 16px margin-top so it's guaranteed to sit a comfortable distance below the hero, even when the body region flexes tight. - Remove the Cancellation Reasons section from the UI. Publisher data on cancellation reasons is sparse (most cancellations bucket as "unknown"), so the section wasn't pulling its weight. Deleted the React component and its render call from SubscribersTab. The storage layer's get_cancellation_reasons method and the REST response's `cancellation_reasons` field stay in place — cheap to keep, surfaces if a future tab wants the same data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The histogram duplicated the information already shown in the percentile callouts above it. Removing it simplifies the section and removes the visual competition for attention. The backend storage method get_subscription_tenure_distribution() is preserved for potential v1.1 tenure visualization revival. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
get_performance_by_product() in both storage classes accepted
DateTimeInterface $start/$end parameters but the SQL never used them.
The orchestrator's cache key included the window, so every distinct
date range allocated a new transient holding identical data.
Per code review, the four columns have different temporal scopes:
active_subs — current state (correctly window-independent)
active_value — current state (correctly window-independent)
lifetime_revenue — lifetime sum, intentionally not windowed; true
LTV waits on the v1.1 BQ wrapper
churned_subs — SHOULD be windowed; this commit fixes the bug
Added a LEFT JOIN to the `_schedule_cancelled` meta (wc_orders_meta
on HPOS, postmeta on legacy) and wrapped the churned-count CASE with
a `sch.meta_value BETWEEN %s AND %s` predicate. Active subscriptions
don't have this meta, so the left-joined row is NULL and the CASE
naturally rejects them. Woo writes at most one _schedule_cancelled
row per subscription, so no row multiplication. Window dates pass
through $wpdb->prepare.
Column scope is now documented at the top of each query body.
Verified end-to-end against local test data:
6-month window: Captain 7 churned, Boss 4, Ambassador 3
1-month window: Captain 1 churned, Boss 0, Ambassador 0
active_subs and lifetime_revenue identical across both windows
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous CASE only covered a small set of explicit period×interval
combos and fell through to `total_amount` for unknown configurations.
That fallthrough was the opposite of conservative — a biennial
subscription (year × 2) was recorded at the full annual amount as if
it were a monthly contribution, inflating MRR by 24x.
New CASE covers all documented Woo billing periods at any positive
integer interval N:
day × N -> total * 30 / N (30-day month)
week × N -> total * (52/12) / N (4.333 weeks per month)
month × N -> total / N
year × N -> total / (12 * N)
The ELSE branch is now truly conservative: falls through to
`total / 12`, which undercounts any non-yearly mis-configuration
rather than inflating it.
Added a diagnostic query that counts active non-donation
subscriptions whose `_billing_period` is not in
('day','week','month','year') OR whose `_billing_interval` casts to 0.
If any exist, logs via Newspack\Logger ('NEWSPACK-INSIGHTS' header) so
the publisher can correct product configuration. The diagnostic
benefits from the same orchestrator-level cache as MRR itself, so the
extra query only runs once per cache window.
Applied to both HPOS_Storage and Legacy_Storage. Verified end-to-end:
local test data is all (month × 1) + (year × 1), so MRR remains
$738.33 — same as before but now mathematically defensible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Donation_Product_Classifier had a flush_cache() method but nothing
in the codebase invoked it on relevant Woo configuration changes.
Publishers reconfiguring donation products would see stale Tab 6 data
for up to one hour while waiting for the 1h TTL.
Added Donation_Product_Classifier::register_hooks() wiring:
- update_option_newspack_donation_product_id -> flush_cache
- added_post_meta / updated_post_meta / deleted_post_meta on the
_newspack_is_donation flag -> flush_cache
The post_meta hooks fire site-wide so the callback filters by
meta_key and early-returns on mismatches.
Insights_Section_Subscribers::register_hooks() now calls
Donation_Product_Classifier::register_hooks() during the tab boot.
Verified end-to-end: all three meta hooks fire with correct key
filtering on real product meta changes (added/updated/deleted of
_newspack_is_donation triggers flush; unrelated keys are skipped).
Option-change hook also fires correctly. The local dev env's object
cache backend has a known delete bug (sets and deletes silently
no-op while values persist in memory), but the callbacks themselves
are invoked correctly and the delete_transient() calls will work
normally in production with a healthy memcached / redis backend.
Also rewrote the MRR comment as a /* */ block with a localized
phpcs:disable so the prose describing the billing math doesn't keep
triggering Squiz.PHP.CommentedOutCode.Found heuristics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nce table
Variable subscription products (the standard monthly/annual variants of
a single membership tier) previously lost their breakdown. The
Performance by product table grouped at the line-item product level,
which in Woo's data model is the parent ID for variations — so a publisher
with Captain Monthly + Captain Annual saw a single Captain row with
silently summed totals.
Both storage classes now query at the resolved-variation level by
COALESCEing `_variation_id` over `_product_id` in the line-item meta
join. Woo writes the PARENT id into `_product_id` and the actual
variation id into `_variation_id` for variable products; the COALESCE
resolves to the variation when present and the standalone product
otherwise. The donation filter continues to read `_product_id` because
the donation set is keyed by the parent in WC's data model.
PHP aggregation reshapes the flat per-variation rows into a parent +
nested variations structure. Each parent entry carries `variations`
sorted by active_subs DESC; standalone products have no `variations`
key. Math reconciles end-to-end:
Captain: parent 20 active = Annual 12 + Monthly 8
parent 7 churned = Annual 5 + Monthly 2
parent $1296 active value = Annual $1200 + Monthly $96
parent $2144 lifetime = Annual $2000 + Monthly $144
Variation labels come from `_subscription_period`: month→Monthly,
year→Annual, week→Weekly, day→Daily. Fallbacks: variation post_title
with parent prefix stripped, then a generic "Variation" string.
The aggregation + label helpers are duplicated across HPOS_Storage and
Legacy_Storage rather than extracted to a trait — they're pure
transformation with no backend-specific logic and Newspack convention
favors duplication over premature abstraction.
Storage_Interface docblock + Subscribers_Metric cache prefix bumped to
v2 (cached shape change).
React:
- PerformanceRow type updated; new PerformanceVariationRow.
- PerformanceSection wraps each parent + its variations in a Fragment
and renders each variation as a `--variation` row with `gray-600`
text and a `padding-left: 44px` Product cell so the indent reads at
a glance. Standalone products render as a single row (no extra rows
after them).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…phpcs
Three small fixes to the nested performance table:
1. Variation row indent was not applying. The `&-cell--indented`
modifier class had specificity (0,1,0), losing to the base
`.table td` rule (0,2,1) — so `padding-left: 44px` never reached
the cell. Rewrote the indent as `&-row--variation td:first-child`
(0,2,2), which wins the cascade cleanly. Dropped the now-dead
`&-cell--indented` className from PerformanceSection too.
2. Variation text was rendering too faded (wp-colors.$gray-600 felt
like disabled/placeholder text against the gray-900 parents).
Bumped to wp-colors.$gray-700 — subordinate to parents but still
clearly part of the same data table.
3. PHPCS CI failure: Squiz inline-comment sniff flagged the bulleted
prose in the HPOS performance query comment ("Expected 1 space
before comment text but found 3"). Converted that block from `//`
lines with indented bullets to a `/* */` block comment so the
list structure survives PHPCS.
Churned-subs zeros are NOT a regression: verified the test data has
no `_schedule_cancelled` dates in the trailing 30 days (latest dated
cancellation is May 4, 2026). The windowed churn count is correct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ss-tab reuse
Per the pre-Tab-7 audit. Two extractions, both mechanical, no
behavior change.
1. MetricCard + format helpers moved from tabs/subscribers/ to a
new tabs/components/ directory. Both are generic — MetricCard's
props were already free of any subscriber-specific shape and the
format helpers (currency / number / percent / delta / tone) are
pure pass-through utilities every future tab will need.
Updated import paths:
ScorecardSection -> '../components/MetricCard'
WindowedSection -> '../components/MetricCard'
PerformanceSection -> '../components/format'
MetricCard's internal './format' import stays relative (same
new directory).
2. tabs/subscribers/subscribers.scss was ~80% generic Insights chrome
despite living in a tab-scoped file. Split into:
tabs/components/sections.scss
- __tab-loading, __tab-error, __tab-error-detail
- __section, __section-heading, __section-caption,
__section-empty
- __metric-grid
- __metric-card and all its sub-rules (-label, -body,
-value, -delta with tones, -description)
- __table-wrap and __table (incl. -num, the
__table-row--variation + td:first-child indent pattern)
tabs/subscribers/subscribers.scss (now slim, Tab 6 only)
- __subscribers-tab orchestrator wrapper
- __tenure-card (the percentile callouts container)
- __stats-summary (the dl layout inside the tenure card)
- __tenure-narrative
style.scss now @use's the shared sections.scss so the chrome
ships once with the main wizard bundle (insights-wizard.css)
instead of inside the lazy-loaded subscribers chunk. Verified:
metric-card / section / table / variation-row rules land in
insights-wizard.css; tenure-card lands in the subscribers chunk
(3352.css).
Tab 7 will inherit the chrome without importing it and only ship
its own tab-specific styles in its own lazy chunk.
Verified: build green, lint clean, REST endpoint unchanged
(active=35, mrr=738, performance rows=4 with the same nested
variation structure).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squiz.Commenting.BlockComment.NoEmptyLineBefore fires when a `/*` block comment follows a `//` line-comment block without an empty line separating them. CI's PHPCS catches it; the local pass missed it because the same file was downstream of another fixed instance on the Tab 7 branch and didn't reproduce there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Implements the first fully data-backed Newspack Insights tab (“Subscribers”), including a WooCommerce-backed PHP data layer (HPOS + legacy), a single REST endpoint to serve the full tab payload, and a React UI that renders snapshot + windowed metrics with optional comparison mode.
Changes:
- Adds Tab 6 (Subscribers) end-to-end: storage abstraction + metric orchestrator +
GET /newspack-insights/v1/subscribersREST endpoint. - Adds the Insights wizard React chrome (tabs, date range picker, comparison toggle, shared “metric card”/table styling) plus stub components for the other tabs.
- Adds Subscribers UI sections (at-a-glance scorecards, windowed metrics, tenure summary, and product performance table) and a client data-fetch hook.
Reviewed changes
Copilot reviewed 47 out of 47 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/newspack-plugin/src/wizards/insights/tabs/SubscribersTab.tsx | Tab 6 orchestrator: fetch + loading/error states + section composition. |
| plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx | Window-scoped metric cards with dynamic heading based on preset/custom range. |
| plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/TenureSection.tsx | Client-side tenure percentile computation + narrative copy. |
| plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/subscribers.scss | Tab 6-specific layout styles (tenure card, spacing). |
| plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx | Snapshot (non-windowed) scorecards: active subs, MRR/ARR, upcoming renewals. |
| plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx | Performance-by-product table rendering including variation nesting. |
| plugins/newspack-plugin/src/wizards/insights/tabs/PromptsTab.tsx | Stub tab UI (“Coming soon”). |
| plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx | Stub tab UI (“Coming soon”). |
| plugins/newspack-plugin/src/wizards/insights/tabs/EngagementTab.tsx | Stub tab UI (“Coming soon”). |
| plugins/newspack-plugin/src/wizards/insights/tabs/DonorsTab.tsx | Stub tab UI (“Coming soon”). |
| plugins/newspack-plugin/src/wizards/insights/tabs/ConversionTab.tsx | Stub tab UI (“Coming soon”). |
| plugins/newspack-plugin/src/wizards/insights/tabs/AudienceTab.tsx | Stub tab UI (“Coming soon”). |
| plugins/newspack-plugin/src/wizards/insights/tabs/AdvertisingTab.tsx | Stub tab UI (“Coming soon”). |
| plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss | Shared cross-tab section/card/table/loading-error styling. |
| plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx | Metric card atom with optional delta rendering + tone semantics. |
| plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts | Intl formatting helpers for numbers/currency/percent/deltas. |
| plugins/newspack-plugin/src/wizards/insights/style.scss | Insights page-level chrome styling + forwards shared tab chrome styles. |
| plugins/newspack-plugin/src/wizards/insights/state/useDateRange.ts | URL-persisted date-range state + preset range computation. |
| plugins/newspack-plugin/src/wizards/insights/state/useComparisonMode.ts | URL-persisted compare toggle + previous-window computation. |
| plugins/newspack-plugin/src/wizards/insights/index.tsx | Insights wizard entrypoint + fallback boot config. |
| plugins/newspack-plugin/src/wizards/insights/hooks/useSubscribersData.ts | Data-fetch lifecycle hook for Subscribers REST endpoint. |
| plugins/newspack-plugin/src/wizards/insights/components/TabNavigation.tsx | Tab bar UI + visibility filtering. |
| plugins/newspack-plugin/src/wizards/insights/components/TabContent.tsx | Lazy-loaded tab panel switcher with Suspense fallback. |
| plugins/newspack-plugin/src/wizards/insights/components/LastUpdated.tsx | “Updated X ago” header timestamp display. |
| plugins/newspack-plugin/src/wizards/insights/components/InsightsWizard.tsx | Top-level Insights wizard chrome (tabs/range/compare routing). |
| plugins/newspack-plugin/src/wizards/insights/components/DateRangePicker.tsx | Preset/custom date range picker component. |
| plugins/newspack-plugin/src/wizards/insights/components/ComparisonToggle.tsx | Compare-to-previous-period checkbox component. |
| plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts | Typed API client for /newspack-insights/v1/subscribers. |
| plugins/newspack-plugin/src/wizards/index.tsx | Registers the Insights wizard entry in the wizards map. |
| plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php | Storage contract for Tab 6 queries. |
| plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-detector.php | Detects HPOS vs legacy storage (cached). |
| plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php | Legacy CPT SQL implementation of Tab 6 storage contract. |
| plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php | HPOS SQL implementation of Tab 6 storage contract. |
| plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php | Metric orchestrator + transient caching + backend dispatch. |
| plugins/newspack-plugin/includes/wizards/insights/classifiers/class-donation-product-classifier.php | Cached donation product ID classifier + invalidation hooks. |
| plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php | PHP wizard registration + boot config localization + feature flag gate. |
| plugins/newspack-plugin/includes/wizards/insights/class-insights-section-subscribers.php | Subscribers section boot: loads dependencies + registers REST route + classifier hooks. |
| plugins/newspack-plugin/includes/wizards/insights/class-insights-section-prompts.php | Prompts section stub init/hook point. |
| plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php | Gates section stub init/hook point. |
| plugins/newspack-plugin/includes/wizards/insights/class-insights-section-engagement.php | Engagement section stub init/hook point. |
| plugins/newspack-plugin/includes/wizards/insights/class-insights-section-donors.php | Donors section stub init/hook point. |
| plugins/newspack-plugin/includes/wizards/insights/class-insights-section-conversion.php | Conversion Journey section stub init/hook point. |
| plugins/newspack-plugin/includes/wizards/insights/class-insights-section-audience.php | Audience section stub init/hook point. |
| plugins/newspack-plugin/includes/wizards/insights/class-insights-section-advertising.php | Advertising section stub init/hook point. |
| plugins/newspack-plugin/includes/wizards/insights/api/class-subscribers-rest-controller.php | REST controller for /newspack-insights/v1/subscribers. |
| plugins/newspack-plugin/includes/class-wizards.php | Registers Insights wizard + initializes Insights section classes. |
| plugins/newspack-plugin/includes/class-newspack.php | Includes new Insights wizard/section PHP files at bootstrap. |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Hey @kmwilkerson, good job getting this PR merged! 🎉 Now, the Please check if this PR needs to be included in the "Upcoming Changes" and "Release Notes" doc. If it doesn't, simply remove the label. If it does, please add an entry to our shared document, with screenshots and testing instructions if applicable, then remove the label. Thank you! ❤️ |
Summary
Builds the fully-functional Subscribers tab inside the Insights wizard. This is the first Insights tab that ships with real publisher data, sourced entirely from local WooCommerce (no BigQuery dependency).
Stacks on the chrome PR (#211, NPPD-1602). Both PRs need to merge for Subscribers to be visible — chrome alone is empty tabs, Subscribers alone won't load without the chrome scaffold.
What's in this PR
Storage abstraction layer
Storage_Interfacedefining the per-metric query contractHPOS_StorageandLegacy_Storageimplementations dispatching bywoocommerce_custom_orders_table_enabledStorage_Detectorcaching the backend detection result (24h TTL)Donation product classifier
Donation_Product_Classifierwrapping the canonical\Newspack\Donations::is_donation_product()andis_donation_order()methodsNOT INfilters across subscription queriesSubscribers metric and REST endpoint
Subscribers_Metricorchestrator with per-method WP transient caching (30min / 60min TTL for v1; will migrate to NPPD-1605 cache table when that lands)GET /newspack-insights/v1/subscribersreturning the full payload (classification metadata, snapshot, current window, optional comparison window). Caching is per-metric inside the orchestrator so a comparison-mode request reuses the same per-method cache entries.React UI
SubscribersTabwith four sections: at-a-glance scorecards, time-windowed metrics, subscriber tenure, performance by productComparison mode
lowerIsBettersemantics for churn count and refund rate (drops render green, increases render red)How to test
NEWSPACK_INSIGHTS_ENABLEDconstant inwp-config.phpwp-admin/admin.php?page=newspack-insights